RecycleView 系列(2)-- 认识 ItemDecoration 类

前言

上一篇 博客介绍了 RecycleView 的基本使用,接下来我们来给列表添加点装饰 - 分割线

RecycleView 没有像 ListView 一样可以直接在 xml 中或者通过 setDivider()方法设置分割线的方法。它是通过 RecycleView 的 addItemDecoration(ItemDecoration decor) 方法来设置的。很显然,我们需要传入一个 ItemDecoration 对象,这个对象是一个抽象类,官方已经提供了一种常用分割线类:DividerItemDecoration。来看一下用法:

1
2
3
// 这里 LayoutManager 为 LinearLayoutManager
DividerItemDecoration itemDecoration = new DividerItemDecoration(this,DividerItemDecoration.VERTICAL);
recycleView.addItemDecoration(itemDecoration )

也很简单有木有,运行代码看下效果:
1.png
那这个简单的效果是如何实现的呢?前面说过 RecycleView 的分割线是由 RecyclerView.ItemDecoration 这个类来实现的,所以我们要先来了解一下这个类。

一.ItemDecoration 类介绍

惯例先看一下文档中对 ItemDecoration 类的介绍:

An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter’s data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.
All ItemDecorations are drawn in the order they were added, before the item views (in onDraw() and after the items (in onDrawOver(Canvas, RecyclerView, RecyclerView.State).
ItemDecoration 允许应用程序为特定的 item 视图添加一个特殊的绘图布局偏移量。这对于绘制 items 之间的分隔线、高亮显示、可视分组等非常有用。
所有的 item 的装饰都是按照被添加的顺序绘制的,先于 item 的绘制(在onDraw() 方法中),和在 item 绘制后 (onDrawOver(Canvas, RecyclerView, RecyclerView.State)方法中)。

从这段介绍中,我们可以得到以下信息:

  • ItemDecoration 可以为 item 添加绘图,还可以设置偏移量
  • ItemDecoration 可以用于实现 item 之间的分隔线、高亮显示、可视分组等功能
  • ItemDecoration 中的 onDraw() 方法先于 item 绘制,onDrawOver(Canvas, RecyclerView, RecyclerView.State方法执行顺序在 item 的绘制之后

让我们来新建一个类,继承 RecyclerView.ItemDecoration,默认会要实现它的三个抽象方法:

1
2
3
4
5
6
7
8
9
10
public class BasicDivider extends RecyclerView.ItemDecoration {
@Override
public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
}
@Override
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
}

看一下文档中对这三个方法的说明:

1.getItemOffsets () 方法:

  • 重新得到给定 item 的任何偏移量。outRect 的每个字段指定 item 视图应该插入多少像素,效果类似于 padding 或 margin。默认将 outRect 的边界设置为 0 并返回;
  • .如果这个 ItemDecoration 不影响 item 视图的位置,在 return 之前要先将 outRect的所有四个字段 (left, top, right, bottom) 设为 0;
  • 如果您需要访问适配器以获取额外的数据,您可以调用 getChildAdapterPosition(View) 来获取该 View 在适配器中位置;

这个方法中比较关键的就是第一点的理解,设置 outRect 的值,效果类似给 item 设置了 padding 或者 margin。我们可以看一下下图理解一下:
2.png
到这其实我们已经可以看到,RecycleView 是通过这个 rectRect 的值来给 item 设置“空隙”,然后达到分割线的效果。

为了更好的理解这个方法,我们在代码中动态设置 outRect 的四个值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class BasicDivider extends RecyclerView.ItemDecoration {

private static final String TAG = "DividerItem";
/**
* 获取 item 在四个方向上的偏移量
*/
private int leftOffset;
private int topOffset;
private int rightOffset;
private int bottomOffset;
private Context context;

public BasicDivider(Context context) {
this(context,0,0,0,0);
}

public BasicDivider(Context context, int left, int top, int right, int bottom) {
this.leftOffset = left;
this.topOffset = top;
this.rightOffset = right;
this.bottomOffset = bottom;
this.context = context;
}
public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
}

@Override
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
outRect.set(leftOffset,topOffset,rightOffset,rightOffset);
}

其中的 leftOffset、topOffset、rightOffset、rightOffset 由外部设置得到,这里代码省略,然后看下效果:

需要注意的是通过 getItemOffsets 方法设置分割线,分割线的颜色是 RecycleView 的 backgroundColor 的颜色。所以我们如果想修改分割线的颜色,就必须修改 recycleView 的背景色。

那么假设我想要一个渐变色的分割线或者其他样式的分割线怎么办呢?
答案就是剩下的两个方法 ondraw()onDrawOver()方法 可以上场了。

2.onDraw () 方法:

Draw any appropriate decorations into the Canvas supplied to the RecyclerView. Any content drawn by this method will be drawn before the item views are drawn, and will thus appear underneath the views
在 RecycleView 的画布中绘制任意合适的装饰。通过该方法绘制的内容将在 item 的绘制之前被绘制,因此会在 item 的下方显示

通过这个介绍我们可以知道以下三点:

  • onDraw() 方法的使用对象是整个 RecycleView,而不是针对单个的 item
  • onDraw()方法可以绘制任何装饰( 通过canvas可以随心所欲)
  • 这个方法在 item 之前被绘制,因此位置计算不当,可能会出现被 item 遮挡的情况

那我们来想一下,如何实现在 RecycleView 背景为灰色,但分割线为 “10 px 的红色” 的功能。这里我写下我分析的步骤:

(1).通过 getItemOffsets()方法设置 item 的间距,预留分割线的宽度
(2).通过 onDraw()方法绘制分割线,具体又细分为:

  • 计算绘制的位置。因为canvas 是这个 RecycleView 的,所以我们要计算每条线的位置
  • 绘制。使用 canvas.drawRect()或者 canvas.drawLine() 方法进行绘制

看一下主要代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 在提供给 RecycleView 的画布中绘制适当的装饰。
@Override
public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
Log.d(TAG,"----onDraw---");
canvas.save();
final int left;
final int right;
left = 0;
right = parent.getRight();
final int childCount = parent.getChildCount();
bottomOffset = 10;
// 遍历 item,获取每个item 的位置从而定位要绘制的分割线的位置
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
//计算分割线的位置
final int bottom = child.getBottom()+bottomOffset;
final int top = child.getBottom();
canvas.drawRect(left,top,right,bottom,paint);
}
canvas.restore();
}

//.重新得到给定 item 的任何偏移量
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//设置 item 底部偏移 10 dp
outRect.bottom = 10;
}

关于分割线位置的计算,我们可以借助一下图,就会很容易计算:
3.png
只要计算 A点和 D 点的坐标即可。当然,我这里写的是最简单的情况,不考虑 RecycleView 及 item 的 padding 和 margin,其实计算的时候也是应该考虑进去的。
然后我们运行看一下效果:
4.png
然后来验证一下,onDraw()方法是在 item 之前绘制的,把上述 onDraw()方法中的 bottomOffset = 50,也就是绘制的区域其实是 item 下方 50 px 高度的矩形。

然后运行,发现效果和上一张图一样。也就是说我们绘制的内容被 item 给覆盖住了。

所以,如果想让内容绘制在 item 之上,不被盖住,我们需要了解下面的方法。

3.onDrawOver () 方法:
onDraw() 方法作用一样,但是是在 item 之后绘制,所以不会被遮挡覆盖。

我们把例子中 onDraw()方法中的代码移到 onDrawOver () 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
Log.d(TAG,"----onDrawOver---");
canvas.save();
final int left;
final int right;
left = 0;
right = parent.getRight();
bottomOffset = 50;
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final int bottom = child.getBottom()+bottomOffset;
final int top = child.getBottom();
canvas.drawRect(left,top,right,bottom,paint);
}
canvas.restore();
}

然后看一下效果:
5.png
可以看到,方法中绘制的内容会覆盖在 item 之上。也就验证了 onDrawOver () 方法在 item 之后绘制。

二.解析默认分割线的实现

了解了 ItemDecoration 各个方法的作用,下面我们来分析一下默认提供的分割线的内部实现,

先看一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
public class DividerItemDecoration extends RecyclerView.ItemDecoration {
public static final int HORIZONTAL = LinearLayout.HORIZONTAL;
public static final int VERTICAL = LinearLayout.VERTICAL;

private static final String TAG = "DividerItem";
private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };

private Drawable mDivider;

/**
* Current orientation. Either {@link #HORIZONTAL} or {@link #VERTICAL}.
*/
private int mOrientation;

private final Rect mBounds = new Rect();

/**
* Creates a divider {@link RecyclerView.ItemDecoration} that can be used with a
* {@link LinearLayoutManager}.
*
* @param context Current context, it will be used to access resources.
* @param orientation Divider orientation. Should be {@link #HORIZONTAL} or {@link #VERTICAL}.
*/
public DividerItemDecoration(Context context, int orientation) {
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0);
if (mDivider == null) {
Log.w(TAG, "@android:attr/listDivider was not set in the theme used for this "
+ "DividerItemDecoration. Please set that attribute all call setDrawable()");
}
a.recycle();
setOrientation(orientation);
}

/**
* Sets the orientation for this divider. This should be called if
* {@link RecyclerView.LayoutManager} changes orientation.
*
* @param orientation {@link #HORIZONTAL} or {@link #VERTICAL}
*/
public void setOrientation(int orientation) {
if (orientation != HORIZONTAL && orientation != VERTICAL) {
throw new IllegalArgumentException(
"Invalid orientation. It should be either HORIZONTAL or VERTICAL");
}
mOrientation = orientation;
}

/**
* Sets the {@link Drawable} for this divider.
*
* @param drawable Drawable that should be used as a divider.
*/
public void setDrawable(@NonNull Drawable drawable) {
if (drawable == null) {
throw new IllegalArgumentException("Drawable cannot be null.");
}
mDivider = drawable;
}

@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getLayoutManager() == null || mDivider == null) {
return;
}
if (mOrientation == VERTICAL) {
drawVertical(c, parent);
} else {
drawHorizontal(c, parent);
}
}

private void drawVertical(Canvas canvas, RecyclerView parent) {
canvas.save();
final int left;
final int right;
//noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
if (parent.getClipToPadding()) {
left = parent.getPaddingLeft();
right = parent.getWidth() - parent.getPaddingRight();
canvas.clipRect(left, parent.getPaddingTop(), right,
parent.getHeight() - parent.getPaddingBottom());
} else {
left = 0;
right = parent.getWidth();
}

final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
parent.getDecoratedBoundsWithMargins(child, mBounds);
final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
final int top = bottom - mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(canvas);
}
canvas.restore();
}

private void drawHorizontal(Canvas canvas, RecyclerView parent) {
canvas.save();
final int top;
final int bottom;
//noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
if (parent.getClipToPadding()) {
top = parent.getPaddingTop();
bottom = parent.getHeight() - parent.getPaddingBottom();
canvas.clipRect(parent.getPaddingLeft(), top,
parent.getWidth() - parent.getPaddingRight(), bottom);
} else {
top = 0;
bottom = parent.getHeight();
}

final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
parent.getLayoutManager().getDecoratedBoundsWithMargins(child, mBounds);
final int right = mBounds.right + Math.round(child.getTranslationX());
final int left = right - mDivider.getIntrinsicWidth();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(canvas);
}
canvas.restore();
}

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
if (mDivider == null) {
outRect.set(0, 0, 0, 0);
return;
}
if (mOrientation == VERTICAL) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}

1.首先来分析一下 getItemOffsets() 方法:

  • mDivider 在构造函数中初始化值,为默认的分割线的 drawable 属性值
  • 判断 LayoutManger 的方向,如果是 Vertical,就设置 item 的 bottom 偏移默认分割线的高度,如果是 Horizontal 就设置 item 的 right 偏移默认分割线的宽度

2,分析一下 onDraw() 方法
首先进行 LayoutManger 方向的判断,不同的方向,分割线位置计算不同,我们这里就拿最常用的 Vertical 时的状态来看,所以具体看一下 drawVertical()方法:

  • 首先通过 parent.getClipToPadding() 判断 RecycleView 是否设置这个参数为 true (默认值),如果为 true,那 RecycleView 显示位置区域就不会包含 padding 值的位置,所以需要进行裁剪,否则,显示 padding 区域,也就是 RecycleView 控件本身占的区域
  • 遍历 item
  • 获取 item 的 坐标位置,包含 item 的 insets 设置的偏移值(比如通过 getItemOffsets 设置的)和 margin 值。
  • 计算绘制区域的位置信息

可以看到,官方代码是先得到一个 drawable 对象,然后计算出 item 所以的偏移值,包括我们通过 getItemOffsets 设置的分割线的值,先计算出 bottom,然后根据 bottom 计算 top。最后通过 onDraw() 设置 drawable 的位置来达到分割线的效果。
无疑这种考虑的更全面,我的 demo 是默认 RecycleView 和 item 都没有偏移量的情况。

所以,可以看到,绘制分割线,分割线位置的计算是比较重要的,需要具体情况具体分析。

三. 利用 shape 设置分割线

不知道大家有没有注意到刚刚看的默认分割线的类中,暴露了这样一个方法:

1
2
3
4
5
6
public void setDrawable(@NonNull Drawable drawable) {
if (drawable == null) {
throw new IllegalArgumentException("Drawable cannot be null.");
}
mDivider = drawable;
}

既然是通过 mDivider.draw(canvas); 最后来绘制分割线的,那么只要我们修改了 drawable 对象,分割线的样式也会修改。

所以,对于简单的分割线,我们可以在 shape 文件中设置一个分割线的样式,然后通过默认分割线的 setDrawable()方法设置。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
 DividerItemDecoration itemDecoration = new DividerItemDecoration(this,DividerItemDecoration.VERTICAL);
itemDecoration.setDrawable(getResources().getDrawable(R.drawable.shape_divider));
rvCommon.addItemDecoration(itemDecoration );

shape:
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#000"
/>
<size android:height="@dimen/space_10"/>
<stroke android:color="@color/colorPrimaryDark"
android:width="@dimen/space_2"/>
</shape>

效果:
6.png

总结

其实分割线的实现很简单,主要掌握 ItemDecoration 内容就好。大致步骤有三点:

1. 通过 getItemOffset() 方法设置 item 的偏移量
2. 在 onDraw()onDrawOver() 方法中完成绘制
2.1 遍历 item,计算分割线的位置
2.2 通过 draw()方法完成绘制

其中需要注意的点有:

  • getItemOffset()方法作用于 item
  • onDraw()onDrawOver() 方法作用于 RecycleView
  • onDraw()先于 item 绘制
  • onDrawOver() 后于 item 绘制
  • 计算分割线位置时需考虑 RecycleView 和 item 自身偏移量的问题

参考

https://developer.android.com/reference/android/support/v7/widget/RecyclerView.ItemDecoration


------------- 本文结束 感谢您的阅读 -------------